fix(hooks): guard-main-checkout reads the path key its routed tool carries - #7686
Conversation
guard-main-checkout.sh extracted exactly one key, .tool_input.file_path, while settings.json routes Edit, Write AND NotebookEdit at it. NotebookEdit carries its path in notebook_path, so every notebook edit yielded an empty path, took the no-path branch and was judged by CLAUDE_PROJECT_DIR instead of by the file. That verdict is a constant per session and wrong in both directions: notebook edits inside a correctly-created worktree were refused when the session was rooted in the shared checkout, and notebook edits INTO the shared checkout were waved through when it was not. The jq-less fallback grepped the same single key and missed it too. The hook now carries a tool-to-path-key table and reads the key the routed tool actually carries, in the jq path and in the text-scan fallback alike. A tool the table does not name keeps the permissive read: any known key it carries, else the project dir. A tool the table DOES name that carries no path under its own key is schema drift, not a missing path, and blocks with a message naming the tool and the key rather than falling back to a verdict about the session. The matcher and the table are now a checked relation, which was the acceptance condition: the self-test reads known_path_keys out of the hook and reds if settings.json routes a tool that has no row in it, so the next tool routed here cannot repeat this defect silently. A row with no matcher entry prints a note. The matrix's known-hole section for this defect flips to the intended verdicts and its banner goes; the unrelated known hole below it is untouched, as are settings.json, the */worktrees/* predicate and guard-main-checkout-bash.sh. The hook's executable lines stay byte-identical to the sibling repo's copy: the two files differ only by the eight comment lines that copy already carried. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
|
ACCEPT — governed in-seat review of PR #7686, head Implemented-by: Verified in a detached compare worktree at the PR head against merge-base Three matrix rows pinned as intended behaviour contradicted the drift ruling and were flipped; each is listed and explained in the PR body — the ruling's consequence, accepted. Landing regime: governed ( Generated by Claude Code |
|
Enqueue request — skills seat (session Generated by Claude Code |
维护者速读(skills 席,2026-09-05T13:2xZ)改了什么:objectui 仓的 worktree 守卫 hook(禁止 agent 在共享主检出上直接改文件)改为按工具读它自带的路径键(Edit / Write / MultiEdit / NotebookEdit 各自的字段),自测 87 → 116;与 objectstack 仓已落地的同一改动(PR #15665)逐字节相同。 为什么改:守卫对没列入的工具读不到路径就会漏放,两仓必须一致,否则同一条纪律在一个仓生效、另一个仓失效。 风险与代价:只改 席位意见:建议入队。 你要做的:点「入队」。 Generated by Claude Code |
Fixes #7260
What was wrong
.claude/settings.jsonroutesEdit|Write|NotebookEditat this hook, and the hook extractedexactly one key,
.tool_input.file_path.NotebookEditdoes not carry that key — its pathkey is
notebook_path. Verified against the tool's own documented input schema rather thanassumed:
notebook_path(absolute, required) andnew_source(required), pluscell_id,cell_type,edit_mode; there is nofile_pathanywhere in a NotebookEdit payload.So every notebook edit produced an empty path, fell into the no-path branch and was judged by
CLAUDE_PROJECT_DIRinstead of by the file being written. The jq-less fallback grepped forthe same single key, so it missed it too. The resulting verdict is a constant per session,
wrong in both directions depending only on where the session happens to be rooted: notebook
edits inside a correctly created worktree were refused, and notebook edits into the shared
primary checkout were waved through.
The fix
The hook now carries one tool-to-path-key table and reads the key the routed tool actually
carries, in the jq path and in the text-scan fallback alike:
known_path_keys='Edit=file_path Write=file_path MultiEdit=file_path NotebookEdit=notebook_path'permissive read: any known key it happens to carry, else the project-dir fallback.
not a missing path. It blocks (exit 2) with a message naming the tool and the key it wanted,
instead of silently falling back to a verdict about the session. That was the seat's ruling
on the frame's contract-tightening axis: tolerance here is exactly what hides the error.
The matcher and the table are now a checked relation
This was triage's acceptance condition — "a tool listed in the matcher" and "a path key the
extractor knows" must be a checkable relation, not one more
//. The self-test's wiringsection reads
known_path_keysout of the hook, splits the matcher on|, and asserts everyrouted tool has a row:
A row with no matcher entry is the harmless direction and prints a note. A tool routed here
with no row is a failure — so the next tool routed at this hook cannot repeat this defect
silently, which was the whole point of not just adding one more key.
Before to after: the notebook rows
The known-hole section flips to the intended verdicts and its banner is gone.
PROJisCLAUDE_PROJECT_DIR; every row points it somewhere that would give the wrong answer under asession-based verdict, so a passing row can only have come from the notebook's own path.
$MAIN$MAIN$WT$MAIN$PLAIN$MAIN$MAIN$WT$WT$WT$MAIN$PLAIN$WT$PLAIN$MAIN/$WT$PLAINRows that were pinned as INTENDED and that this change flips
Per the ruling: a row pinned as intended behaviour (not under a known-hole banner) that
contradicts the drift rule gets flipped, listed, and explained. Three places:
tool_name=NotebookEdit into $WTwasallow, nowblock. That row fed afile_path-carrying payload under the nameNotebookEdit. Under the fix that is a routedtool with no path under its own key, i.e. drift. The section's premise — "tool_name is
never consulted" — is itself falsified by the fix (tool_name now selects the key, though
the verdict still comes from the path alone), so the section was rewritten rather than
patched, and it now covers Edit/Write/MultiEdit by
file_path, NotebookEdit bynotebook_path, and an unrouted tool through both keys.no usable path, CLAUDE_PROJECT_DIR=$WTand=$PLAINfor the probe{"tool_name":"Edit","tool_input":{}}wereallow, nowblock. Routed tool, no pathunder its key: drift, so the session's root no longer decides.
CLAUDE_PROJECT_DIRunset section's probe was re-pointed, not flipped. It existsto measure the cwd fallback, and that branch still exists for unrouted payloads, so its
probe is now an unrouted
AnythingElseand its three verdicts are unchanged. The routedEditprobe moved into the new drift section, where it is asserted asblockunder allthree roots.
The remaining known hole in the matrix — the
*/worktrees/*substring predicate — isuntouched, banner and all.
Self-test counts
guard-main-checkout.selftest.shguard-main-checkout.selftest.shRun against the old matrix, the new hook produces exactly 9 failures — the 9 rows listed
above and nothing else, in both repos. No unlisted row changed.
The jq-less fallback is exercised, not assumed: the matrix runs the notebook rows, the drift
row and the decoy rows a second time with
jqremoved fromPATH(the existingnojqfixture), because a fallback that still reads one key sends notebook edits straight back to
the no-path branch whenever jq is away.
Non-vacuity
Each new class was driven red against a mutated copy of the hook (
GUARD_MAIN_CHECKOUT_HOOKpoints the matrix at it, so the tree is never mutated and there is no restore leg to get
wrong). Each mutation was proved on disk by grepping for the injected and the removed text
before the matrix was run, and the hook blob in the worktree was checked equal to its HEAD
blob. Identical results in both repos:
file_pathknown_path_keysFAIL NotebookEdit is routed to this hook but has no row in known_path_keys, which is the pairing check doing its jobCross-repo convergence
The two hooks' executable lines are byte-identical before and after. Diff of the two files
after this change, comments stripped: empty. Full diff, comments included:
That is the same eight comment lines the objectstack copy carried before this change, and no
executable line differs. The self-tests differ only where they already did: objectui's porting
header, and the remaining known hole's issue reference pointing at each repo's own card.
Exclusions
.claude/settings.json— untouched (the matcher is not changed).*/worktrees/*predicate and its known-hole section — untouched.guard-main-checkout-bash.sh— untouched; it parses.tool_input.commandand never seesnotebook payloads.
Sibling PR carrying the byte-identical executable diff:
objectstack-ai/objectstackbranchclaude/issue-11810-guard-hook-notebook-pathforobjectstack#11810. The two repos move together, as triage required.
Gates
This repo has no
dispatch-gatesderivation, so the family was found by asking which checkscripts read
.claude:grep -l '\.claude' scripts/*.mjs, cross-read against thecheck:*scripts in
package.json. Exit codes captured by redirecting first, never across a pipe.node scripts/check-control-bytes.mjsOK (scanned 6258 tracked text file(s); skipped 85 binary).node scripts/check-shell-escape-residue.mjsOK (5/5 root(s) resolved ... 206 file(s) and 1307 fenced block(s) examined in total)node scripts/check-governed-queue-guard.mjs --self-testOK check-governed-queue-guard self-test: 132 cases passnode scripts/check-skills-paths.mjsOK (88/89 stated path(s) resolve across 20 guide file(s); 1 baselined).node scripts/check-skill-eval-tokens.mjsRed under the chosen oracle: 0 (0 beyond the baseline).node scripts/check-changeset-presence.mjsnode scripts/check-skill-examples.mjscheck-skill-examples.mjsexits 2 withthe example program was NOT run: the packages it resolves against are not built, and its own source declaresexport const SCAN_ROOTS = ['skills', '.claude/skills']. This change set is.claude/hooks/**— outside both scan roots — so building the ten package closures its--build-filternames would measure the same tree asmain. Recorded as NOT MEASURED withthat reason; CI runs it regardless.
Changeset
This repo has no
skip-changesetlabel, soscripts/check-changeset-presence.mjsdecides,and it was run:
No changeset added, per that verdict.
pnpm lint— NOT MEASURED, by eslint's own configuration, not by a choice of mine.Asked directly about the two changed files, eslint answers per file:
Both changed files are shell, outside eslint's configured surface entirely, so this diff
cannot move any verdict about a file eslint does lint. In the sibling repo the same shell
surface is covered by
check:bash32-floor(green over 27 files,.claude/hooks/**among itsscan roots); this repo has no equivalent shell gate, which is a gap worth its own card rather
than something to fix here.
Generated by Claude Code
Generated by Claude Code